1→{
2→ "parentUuid": "69b34896-3549-48f8-ad9f-64d4be1cac95",
3→ "isSidechain": false,
4→ "userType": "external",
5→ "cwd": "/Users/dain/workspace/claude-code-log",
6→ "sessionId": "1b6cf74c-6350-40c0-91c1-d75c257c0729",
7→ "version": "1.0.24",
8→ "type": "user",
9→ "message": {
10→ "role": "user",
11→ "content": [
12→ {
13→ "tool_use_id": "toolu_01LRCsYX5dNHNaZLfAF2RLDh",
14→ "type": "tool_result",
15→ "content": " 1→#!/usr/bin/env python3\n 2→\"\"\"Convert Claude transcript JSONL files to HTML.\"\"\"\n 3→\n 4→import json\n 5→from pathlib import Path\n 6→from typing import Any, Dict, List, Optional\n 7→from datetime import datetime\n 8→import dateparser\n 9→import html\n 10→\n 11→\n 12→def extract_text_content(content: Any) -> str:\n 13→ \"\"\"Extract text content from Claude message content structure.\"\"\"\n 14→ if isinstance(content, list):\n 15→ text_parts = []\n 16→ for item in content:\n 17→ if isinstance(item, dict):\n 18→ if item.get(\"type\") == \"text\":\n 19→ text_parts.append(item.get(\"text\", \"\"))\n 20→ # Skip tool_use and tool_result items since we only want user messages\n 21→ return \"\\n\".join(text_parts)\n 22→ elif isinstance(content, dict):\n 23→ if content.get(\"type\") == \"text\":\n 24→ return content.get(\"text\", \"\")\n 25→ return str(content) if content else \"\"\n 26→\n 27→\n 28→def format_timestamp(timestamp_str: str) -> str:\n 29→ \"\"\"Format ISO timestamp for display.\"\"\"\n 30→ try:\n 31→ dt = datetime.fromisoformat(timestamp_str.replace(\"Z\", \"+00:00\"))\n 32→ return dt.strftime(\"%Y-%m-%d %H:%M:%S\")\n 33→ except (ValueError, AttributeError):\n 34→ return timestamp_str\n 35→\n 36→\n 37→def parse_timestamp(timestamp_str: str) -> Optional[datetime]:\n 38→ \"\"\"Parse ISO timestamp to datetime object.\"\"\"\n 39→ try:\n 40→ return datetime.fromisoformat(timestamp_str.replace(\"Z\", \"+00:00\"))\n 41→ except (ValueError, AttributeError):\n 42→ return None\n 43→\n 44→\n 45→def filter_messages_by_date(\n 46→ messages: List[Dict[str, Any]], from_date: Optional[str], to_date: Optional[str]\n 47→) -> List[Dict[str, Any]]:\n 48→ \"\"\"Filter messages based on date range.\"\"\"\n 49→ if not from_date and not to_date:\n 50→ return messages\n 51→\n 52→ # Parse the date strings using dateparser\n 53→ from_dt = None\n 54→ to_dt = None\n 55→\n 56→ if from_date:\n 57→ from_dt = dateparser.parse(from_date)\n 58→ if not from_dt:\n 59→ raise ValueError(f\"Could not parse from-date: {from_date}\")\n 60→ # If parsing relative dates like \"today\", start from beginning of day\n 61→ if from_date in [\"today\", \"yesterday\"] or \"days ago\" in from_date:\n 62→ from_dt = from_dt.replace(hour=0, minute=0, second=0, microsecond=0)\n 63→\n 64→ if to_date:\n 65→ to_dt = dateparser.parse(to_date)\n 66→ if not to_dt:\n 67→ raise ValueError(f\"Could not parse to-date: {to_date}\")\n 68→ # If parsing relative dates like \"today\", end at end of day\n 69→ if to_date in [\"today\", \"yesterday\"] or \"days ago\" in to_date:\n 70→ to_dt = to_dt.replace(hour=23, minute=59, second=59, microsecond=999999)\n 71→\n 72→ filtered_messages = []\n 73→ for message in messages:\n 74→ timestamp_str = message.get(\"timestamp\", \"\")\n 75→ if not timestamp_str:\n 76→ continue\n 77→\n 78→ message_dt = parse_timestamp(timestamp_str)\n 79→ if not message_dt:\n 80→ continue\n 81→\n 82→ # Convert to naive datetime for comparison (dateparser returns naive datetimes)\n 83→ if message_dt.tzinfo:\n 84→ message_dt = message_dt.replace(tzinfo=None)\n 85→\n 86→ # Check if message falls within date range\n 87→ if from_dt and message_dt < from_dt:\n 88→ continue\n 89→ if to_dt and message_dt > to_dt:\n 90→ continue\n 91→\n 92→ filtered_messages.append(message)\n 93→\n 94→ return filtered_messages\n 95→\n 96→\n 97→def load_transcript(jsonl_path: Path) -> List[Dict[str, Any]]:\n 98→ \"\"\"Load and parse JSONL transcript file.\"\"\"\n 99→ messages = []\n 100→ with open(jsonl_path, \"r\", encoding=\"utf-8\") as f:\n 101→ for line in f:\n 102→ line = line.strip()\n 103→ if line:\n 104→ try:\n 105→ entry = json.loads(line)\n 106→ if entry.get(\"type\") in [\"user\", \"assistant\", \"summary\"]:\n 107→ # Add source file info for session tracking\n 108→ entry[\"_source_file\"] = jsonl_path.stem\n 109→ messages.append(entry)\n 110→ except json.JSONDecodeError:\n 111→ continue\n 112→ return messages\n 113→\n 114→\n 115→def load_directory_transcripts(directory_path: Path) -> List[Dict[str, Any]]:\n 116→ \"\"\"Load all JSONL transcript files from a directory and combine them.\"\"\"\n 117→ all_messages = []\n 118→\n 119→ # Find all .jsonl files\n 120→ jsonl_files = list(directory_path.glob(\"*.jsonl\"))\n 121→\n 122→ for jsonl_file in jsonl_files:\n 123→ messages = load_transcript(jsonl_file)\n 124→ all_messages.extend(messages)\n 125→\n 126→ # Sort all messages chronologically\n 127→ all_messages.sort(key=lambda x: x.get(\"timestamp\", \"\"))\n 128→\n 129→ return all_messages\n 130→\n 131→\n 132→def escape_html(text: str) -> str:\n 133→ \"\"\"Escape HTML special characters in text.\"\"\"\n 134→ return html.escape(text)\n 135→\n 136→\n 137→def extract_command_name(text_content: str) -> str:\n 138→ \"\"\"Extract command name from system message with command tags.\"\"\"\n 139→ import re\n 140→\n 141→ command_name_match = re.search(\n 142→ r\"<command-name>([^<]+)</command-name>\", text_content\n 143→ )\n 144→ if command_name_match:\n 145→ return command_name_match.group(1).strip()\n 146→ return \"system\"\n 147→\n 148→\n 149→def is_system_message(text_content: str, message_content: dict) -> bool:\n 150→ \"\"\"Check if a message is a system message that should be filtered out.\"\"\"\n 151→ system_message_patterns = [\n 152→ \"Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.\",\n 153→ \"[Request interrupted by user for tool use]\",\n 154→ ]\n 155→\n 156→ # Check for command output messages with <local-command-stdout> tags\n 157→ if \"<local-command-stdout>\" in text_content:\n 158→ return True\n 159→\n 160→ # Check for tool result messages - these have tool_use_id and type=\"tool_result\"\n 161→ if isinstance(message_content, dict) and \"content\" in message_content:\n 162→ content = message_content[\"content\"]\n 163→ if isinstance(content, list):\n 164→ for item in content:\n 165→ if isinstance(item, dict) and item.get(\"type\") == \"tool_result\":\n 166→ return True\n 167→\n 168→ return any(pattern in text_content for pattern in system_message_patterns)\n 169→\n 170→\n 171→def generate_html(messages: List[Dict[str, Any]], title: Optional[str] = None) -> str:\n 172→ \"\"\"Generate HTML from transcript messages.\"\"\"\n 173→ if not title:\n 174→ title = \"Claude Transcript\"\n 175→\n 176→ html_parts = [\n 177→ \"<!DOCTYPE html>\",\n 178→ \"<html lang='en'>\",\n 179→ \"<head>\",\n 180→ \" <meta charset='UTF-8'>\",\n 181→ \" <meta name='viewport' content='width=device-width, initial-scale=1.0'>\",\n 182→ f\" <title>{title}</title>\",\n 183→ ' <script type=\"module\">',\n 184→ \" import { marked } from 'https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js';\",\n 185→ \" \",\n 186→ \" // Configure marked options\",\n 187→ \" marked.setOptions({\",\n 188→ \" breaks: true,\",\n 189→ \" gfm: true\",\n 190→ \" });\",\n 191→ \" \",\n 192→ \" document.addEventListener('DOMContentLoaded', function() {\",\n 193→ \" // Find all content divs and render markdown\",\n 194→ \" const contentDivs = document.querySelectorAll('.content');\",\n 195→ \" contentDivs.forEach(div => {\",\n 196→ \" // Skip if it's already HTML (contains tags)\",\n 197→ \" if (div.innerHTML.includes('<') && div.innerHTML.includes('>')) {\",\n 198→ \" return;\",\n 199→ \" }\",\n 200→ \" \",\n 201→ \" const markdownText = div.textContent;\",\n 202→ \" if (markdownText.trim()) {\",\n 203→ \" div.innerHTML = marked.parse(markdownText);\",\n 204→ \" }\",\n 205→ \" });\",\n 206→ \" });\",\n 207→ \" </script>\",\n 208→ \" <style>\",\n 209→ \" body {\",\n 210→ \" font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', 'Ubuntu Mono', 'Cascadia Code', 'Menlo', 'Consolas', monospace;\",\n 211→ \" line-height: 1.5;\",\n 212→ \" max-width: 1200px;\",\n 213→ \" margin: 0 auto;\",\n 214→ \" padding: 10px;\",\n 215→ \" background-color: #fafafa;\",\n 216→ \" color: #333;\",\n 217→ \" }\",\n 218→ \" .message {\",\n 219→ \" margin-bottom: 12px;\",\n 220→ \" padding: 12px;\",\n 221→ \" border-radius: 6px;\",\n 222→ \" border-left: 3px solid;\",\n 223→ \" }\",\n 224→ \" .session-divider {\",\n 225→ \" margin: 30px 0;\",\n 226→ \" padding: 8px 0;\",\n 227→ \" border-top: 2px solid #ddd;\",\n 228→ \" text-align: center;\",\n 229→ \" font-weight: 600;\",\n 230→ \" color: #666;\",\n 231→ \" font-size: 0.9em;\",\n 232→ \" }\",\n 233→ \" .user {\",\n 234→ \" background-color: #e3f2fd;\",\n 235→ \" border-left-color: #2196f3;\",\n 236→ \" }\",\n 237→ \" .assistant {\",\n 238→ \" background-color: #f3e5f5;\",\n 239→ \" border-left-color: #9c27b0;\",\n 240→ \" }\",\n 241→ \" .summary {\",\n 242→ \" background-color: #e8f5e8;\",\n 243→ \" border-left-color: #4caf50;\",\n 244→ \" }\",\n 245→ \" .system {\",\n 246→ \" background-color: #fff8e1;\",\n 247→ \" border-left-color: #ff9800;\",\n 248→ \" }\",\n 249→ \" details {\",\n 250→ \" margin-top: 8px;\",\n 251→ \" }\",\n 252→ \" details summary {\",\n 253→ \" font-weight: 600;\",\n 254→ \" cursor: pointer;\",\n 255→ \" padding: 4px 0;\",\n 256→ \" color: #666;\",\n 257→ \" }\",\n 258→ \" details[open] summary {\",\n 259→ \" margin-bottom: 8px;\",\n 260→ \" }\",\n 261→ \" .duplicate-collapsed {\",\n 262→ \" background-color: #fff3cd;\",\n 263→ \" border-left-color: #ffc107;\",\n 264→ \" opacity: 0.7;\",\n 265→ \" }\",\n 266→ \" .header {\",\n 267→ \" font-weight: 600;\",\n 268→ \" margin-bottom: 8px;\",\n 269→ \" display: flex;\",\n 270→ \" justify-content: space-between;\",\n 271→ \" align-items: center;\",\n 272→ \" }\",\n 273→ \" .timestamp {\",\n 274→ \" font-size: 0.85em;\",\n 275→ \" color: #666;\",\n 276→ \" font-weight: normal;\",\n 277→ \" }\",\n 278→ \" .content {\",\n 279→ \" white-space: pre-wrap;\",\n 280→ \" word-wrap: break-word;\",\n 281→ \" }\",\n 282→ \" .duplicate-note {\",\n 283→ \" font-style: italic;\",\n 284→ \" color: #856404;\",\n 285→ \" font-size: 0.9em;\",\n 286→ \" margin-top: 8px;\",\n 287→ \" }\",\n 288→ \" h1 {\",\n 289→ \" text-align: center;\",\n 290→ \" color: #2c3e50;\",\n 291→ \" margin-bottom: 20px;\",\n 292→ \" font-size: 1.8em;\",\n 293→ \" }\",\n 294→ \" code {\",\n 295→ \" background-color: #f5f5f5;\",\n 296→ \" padding: 2px 4px;\",\n 297→ \" border-radius: 3px;\",\n 298→ \" font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;\",\n 299→ \" }\",\n 300→ \" pre {\",\n 301→ \" background-color: #f5f5f5;\",\n 302→ \" padding: 10px;\",\n 303→ \" border-radius: 5px;\",\n 304→ \" overflow-x: auto;\",\n 305→ \" }\",\n 306→ \" </style>\",\n 307→ \"</head>\",\n 308→ \"<body>\",\n 309→ f\" <h1>{title}</h1>\",\n 310→ ]\n 311→\n 312→ current_session = None\n 313→ last_message_content = None\n 314→\n 315→ for message in messages:\n 316→ message_type = message.get(\"type\", \"unknown\")\n 317→\n 318→ # Extract message content first to check for duplicates\n 319→ message_content = message.get(\"message\", {})\n 320→ if isinstance(message_content, dict):\n 321→ content = message_content.get(\"content\", \"\")\n 322→ else:\n 323→ content = message_content\n 324→\n 325→ text_content = extract_text_content(content)\n 326→ if not text_content.strip():\n 327→ continue\n 328→\n 329→ # Handle system messages with command names\n 330→ is_system = is_system_message(text_content, message_content)\n 331→ is_command_message = (\n 332→ \"<command-name>\" in text_content and \"<command-message>\" in text_content\n 333→ )\n 334→\n 335→ if is_system and not is_command_message:\n 336→ continue\n 337→\n 338→ # Check if we're in a new session\n 339→ source_file = message.get(\"_source_file\", \"unknown\")\n 340→ is_new_session = current_session != source_file\n 341→\n 342→ # Check for duplicate message at session boundary\n 343→ is_duplicate = (\n 344→ is_new_session\n 345→ and last_message_content is not None\n 346→ and text_content.strip() == last_message_content.strip()\n 347→ )\n 348→\n 349→ if is_new_session:\n 350→ if current_session is not None: # Don't add divider before first session\n 351→ html_parts.append(\n 352→ f\" <div class='session-divider'>Session: {source_file}</div>\"\n 353→ )\n 354→ else:\n 355→ html_parts.append(\n 356→ f\" <div class='session-divider'>Session: {source_file}</div>\"\n 357→ )\n 358→ current_session = source_file\n 359→\n 360→ timestamp = message.get(\"timestamp\", \"\")\n 361→ formatted_timestamp = format_timestamp(timestamp) if timestamp else \"\"\n 362→\n 363→ # Determine CSS class and content based on message type and duplicate status\n 364→ if message_type == \"summary\":\n 365→ css_class = \"summary\"\n 366→ summary_text = message.get(\"summary\", \"Summary\")\n 367→ content_html = f\"<strong>Summary:</strong> {escape_html(str(summary_text))}\"\n 368→ elif is_command_message:\n 369→ css_class = \"system\"\n 370→ command_name = extract_command_name(text_content)\n 371→ escaped_command_name = escape_html(command_name)\n 372→ escaped_content = escape_html(text_content)\n 373→ content_html = f\"<details><summary>Command: {escaped_command_name}</summary><div class='content'>{escaped_content}</div></details>\"\n 374→ message_type = \"system\"\n 375→ else:\n 376→ css_class = f\"{message_type}\"\n 377→ content_html = escape_html(text_content)\n 378→\n 379→ if is_duplicate:\n 380→ css_class += \" duplicate-collapsed\"\n 381→ content_html = \"<div class='duplicate-note'>(Duplicate from previous session - collapsed)</div>\"\n 382→\n 383→ html_parts.extend(\n 384→ [\n 385→ f\" <div class='message {css_class}'>\",\n 386→ \" <div class='header'>\",\n 387→ f\" <span>{message_type.title()}</span>\",\n 388→ f\" <span class='timestamp'>{formatted_timestamp}</span>\",\n 389→ \" </div>\",\n 390→ f\" <div class='content'>{content_html}</div>\",\n 391→ \" </div>\",\n 392→ ]\n 393→ )\n 394→\n 395→ # Update last message content for duplicate detection\n 396→ last_message_content = text_content\n 397→\n 398→ html_parts.extend([\"</body>\", \"</html>\"])\n 399→\n 400→ return \"\\n\".join(html_parts)\n 401→\n 402→\n 403→def convert_jsonl_to_html(\n 404→ input_path: Path,\n 405→ output_path: Optional[Path] = None,\n 406→ from_date: Optional[str] = None,\n 407→ to_date: Optional[str] = None,\n 408→) -> Path:\n 409→ \"\"\"Convert JSONL transcript(s) to HTML file.\"\"\"\n 410→ if not input_path.exists():\n 411→ raise FileNotFoundError(f\"Input path not found: {input_path}\")\n 412→\n 413→ if input_path.is_file():\n 414→ # Single file mode\n 415→ if output_path is None:\n 416→ output_path = input_path.with_suffix(\".html\")\n 417→ messages = load_transcript(input_path)\n 418→ title = f\"Claude Transcript - {input_path.stem}\"\n 419→ else:\n 420→ # Directory mode\n 421→ if output_path is None:\n 422→ output_path = input_path / \"combined_transcripts.html\"\n 423→ messages = load_directory_transcripts(input_path)\n 424→ title = f\"Claude Transcripts - {input_path.name}\"\n 425→\n 426→ # Apply date filtering\n 427→ messages = filter_messages_by_date(messages, from_date, to_date)\n 428→\n 429→ # Update title to include date range if specified\n 430→ if from_date or to_date:\n 431→ date_range_parts = []\n 432→ if from_date:\n 433→ date_range_parts.append(f\"from {from_date}\")\n 434→ if to_date:\n 435→ date_range_parts.append(f\"to {to_date}\")\n 436→ date_range_str = \" \".join(date_range_parts)\n 437→ title += f\" ({date_range_str})\"\n 438→\n 439→ html_content = generate_html(messages, title)\n 440→ output_path.write_text(html_content, encoding=\"utf-8\")\n 441→ return output_path\n 442→"
16→ }
17→ ]
18→ },
19→ "uuid": "2ddd3087-623f-483e-956f-51b5d35084db",
20→ "timestamp": "2025-06-14T11:42:31.733Z",
21→ "toolUseResult": {
22→ "type": "text",
23→ "file": {
24→ "filePath": "/Users/dain/workspace/claude-code-log/claude_code_log/converter.py",
25→ "content": "#!/usr/bin/env python3\n\"\"\"Convert Claude transcript JSONL files to HTML.\"\"\"\n\nimport json\nfrom pathlib import Path\nfrom typing import Any, Dict, List, Optional\nfrom datetime import datetime\nimport dateparser\nimport html\n\n\ndef extract_text_content(content: Any) -> str:\n \"\"\"Extract text content from Claude message content structure.\"\"\"\n if isinstance(content, list):\n text_parts = []\n for item in content:\n if isinstance(item, dict):\n if item.get(\"type\") == \"text\":\n text_parts.append(item.get(\"text\", \"\"))\n # Skip tool_use and tool_result items since we only want user messages\n return \"\\n\".join(text_parts)\n elif isinstance(content, dict):\n if content.get(\"type\") == \"text\":\n return content.get(\"text\", \"\")\n return str(content) if content else \"\"\n\n\ndef format_timestamp(timestamp_str: str) -> str:\n \"\"\"Format ISO timestamp for display.\"\"\"\n try:\n dt = datetime.fromisoformat(timestamp_str.replace(\"Z\", \"+00:00\"))\n return dt.strftime(\"%Y-%m-%d %H:%M:%S\")\n except (ValueError, AttributeError):\n return timestamp_str\n\n\ndef parse_timestamp(timestamp_str: str) -> Optional[datetime]:\n \"\"\"Parse ISO timestamp to datetime object.\"\"\"\n try:\n return datetime.fromisoformat(timestamp_str.replace(\"Z\", \"+00:00\"))\n except (ValueError, AttributeError):\n return None\n\n\ndef filter_messages_by_date(\n messages: List[Dict[str, Any]], from_date: Optional[str], to_date: Optional[str]\n) -> List[Dict[str, Any]]:\n \"\"\"Filter messages based on date range.\"\"\"\n if not from_date and not to_date:\n return messages\n\n # Parse the date strings using dateparser\n from_dt = None\n to_dt = None\n\n if from_date:\n from_dt = dateparser.parse(from_date)\n if not from_dt:\n raise ValueError(f\"Could not parse from-date: {from_date}\")\n # If parsing relative dates like \"today\", start from beginning of day\n if from_date in [\"today\", \"yesterday\"] or \"days ago\" in from_date:\n from_dt = from_dt.replace(hour=0, minute=0, second=0, microsecond=0)\n\n if to_date:\n to_dt = dateparser.parse(to_date)\n if not to_dt:\n raise ValueError(f\"Could not parse to-date: {to_date}\")\n # If parsing relative dates like \"today\", end at end of day\n if to_date in [\"today\", \"yesterday\"] or \"days ago\" in to_date:\n to_dt = to_dt.replace(hour=23, minute=59, second=59, microsecond=999999)\n\n filtered_messages = []\n for message in messages:\n timestamp_str = message.get(\"timestamp\", \"\")\n if not timestamp_str:\n continue\n\n message_dt = parse_timestamp(timestamp_str)\n if not message_dt:\n continue\n\n # Convert to naive datetime for comparison (dateparser returns naive datetimes)\n if message_dt.tzinfo:\n message_dt = message_dt.replace(tzinfo=None)\n\n # Check if message falls within date range\n if from_dt and message_dt < from_dt:\n continue\n if to_dt and message_dt > to_dt:\n continue\n\n filtered_messages.append(message)\n\n return filtered_messages\n\n\ndef load_transcript(jsonl_path: Path) -> List[Dict[str, Any]]:\n \"\"\"Load and parse JSONL transcript file.\"\"\"\n messages = []\n with open(jsonl_path, \"r\", encoding=\"utf-8\") as f:\n for line in f:\n line = line.strip()\n if line:\n try:\n entry = json.loads(line)\n if entry.get(\"type\") in [\"user\", \"assistant\", \"summary\"]:\n # Add source file info for session tracking\n entry[\"_source_file\"] = jsonl_path.stem\n messages.append(entry)\n except json.JSONDecodeError:\n continue\n return messages\n\n\ndef load_directory_transcripts(directory_path: Path) -> List[Dict[str, Any]]:\n \"\"\"Load all JSONL transcript files from a directory and combine them.\"\"\"\n all_messages = []\n\n # Find all .jsonl files\n jsonl_files = list(directory_path.glob(\"*.jsonl\"))\n\n for jsonl_file in jsonl_files:\n messages = load_transcript(jsonl_file)\n all_messages.extend(messages)\n\n # Sort all messages chronologically\n all_messages.sort(key=lambda x: x.get(\"timestamp\", \"\"))\n\n return all_messages\n\n\ndef escape_html(text: str) -> str:\n \"\"\"Escape HTML special characters in text.\"\"\"\n return html.escape(text)\n\n\ndef extract_command_name(text_content: str) -> str:\n \"\"\"Extract command name from system message with command tags.\"\"\"\n import re\n\n command_name_match = re.search(\n r\"<command-name>([^<]+)</command-name>\", text_content\n )\n if command_name_match:\n return command_name_match.group(1).strip()\n return \"system\"\n\n\ndef is_system_message(text_content: str, message_content: dict) -> bool:\n \"\"\"Check if a message is a system message that should be filtered out.\"\"\"\n system_message_patterns = [\n \"Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.\",\n \"[Request interrupted by user for tool use]\",\n ]\n\n # Check for command output messages with <local-command-stdout> tags\n if \"<local-command-stdout>\" in text_content:\n return True\n\n # Check for tool result messages - these have tool_use_id and type=\"tool_result\"\n if isinstance(message_content, dict) and \"content\" in message_content:\n content = message_content[\"content\"]\n if isinstance(content, list):\n for item in content:\n if isinstance(item, dict) and item.get(\"type\") == \"tool_result\":\n return True\n\n return any(pattern in text_content for pattern in system_message_patterns)\n\n\ndef generate_html(messages: List[Dict[str, Any]], title: Optional[str] = None) -> str:\n \"\"\"Generate HTML from transcript messages.\"\"\"\n if not title:\n title = \"Claude Transcript\"\n\n html_parts = [\n \"<!DOCTYPE html>\",\n \"<html lang='en'>\",\n \"<head>\",\n \" <meta charset='UTF-8'>\",\n \" <meta name='viewport' content='width=device-width, initial-scale=1.0'>\",\n f\" <title>{title}</title>\",\n ' <script type=\"module\">',\n \" import { marked } from 'https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js';\",\n \" \",\n \" // Configure marked options\",\n \" marked.setOptions({\",\n \" breaks: true,\",\n \" gfm: true\",\n \" });\",\n \" \",\n \" document.addEventListener('DOMContentLoaded', function() {\",\n \" // Find all content divs and render markdown\",\n \" const contentDivs = document.querySelectorAll('.content');\",\n \" contentDivs.forEach(div => {\",\n \" // Skip if it's already HTML (contains tags)\",\n \" if (div.innerHTML.includes('<') && div.innerHTML.includes('>')) {\",\n \" return;\",\n \" }\",\n \" \",\n \" const markdownText = div.textContent;\",\n \" if (markdownText.trim()) {\",\n \" div.innerHTML = marked.parse(markdownText);\",\n \" }\",\n \" });\",\n \" });\",\n \" </script>\",\n \" <style>\",\n \" body {\",\n \" font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', 'Ubuntu Mono', 'Cascadia Code', 'Menlo', 'Consolas', monospace;\",\n \" line-height: 1.5;\",\n \" max-width: 1200px;\",\n \" margin: 0 auto;\",\n \" padding: 10px;\",\n \" background-color: #fafafa;\",\n \" color: #333;\",\n \" }\",\n \" .message {\",\n \" margin-bottom: 12px;\",\n \" padding: 12px;\",\n \" border-radius: 6px;\",\n \" border-left: 3px solid;\",\n \" }\",\n \" .session-divider {\",\n \" margin: 30px 0;\",\n \" padding: 8px 0;\",\n \" border-top: 2px solid #ddd;\",\n \" text-align: center;\",\n \" font-weight: 600;\",\n \" color: #666;\",\n \" font-size: 0.9em;\",\n \" }\",\n \" .user {\",\n \" background-color: #e3f2fd;\",\n \" border-left-color: #2196f3;\",\n \" }\",\n \" .assistant {\",\n \" background-color: #f3e5f5;\",\n \" border-left-color: #9c27b0;\",\n \" }\",\n \" .summary {\",\n \" background-color: #e8f5e8;\",\n \" border-left-color: #4caf50;\",\n \" }\",\n \" .system {\",\n \" background-color: #fff8e1;\",\n \" border-left-color: #ff9800;\",\n \" }\",\n \" details {\",\n \" margin-top: 8px;\",\n \" }\",\n \" details summary {\",\n \" font-weight: 600;\",\n \" cursor: pointer;\",\n \" padding: 4px 0;\",\n \" color: #666;\",\n \" }\",\n \" details[open] summary {\",\n \" margin-bottom: 8px;\",\n \" }\",\n \" .duplicate-collapsed {\",\n \" background-color: #fff3cd;\",\n \" border-left-color: #ffc107;\",\n \" opacity: 0.7;\",\n \" }\",\n \" .header {\",\n \" font-weight: 600;\",\n \" margin-bottom: 8px;\",\n \" display: flex;\",\n \" justify-content: space-between;\",\n \" align-items: center;\",\n \" }\",\n \" .timestamp {\",\n \" font-size: 0.85em;\",\n \" color: #666;\",\n \" font-weight: normal;\",\n \" }\",\n \" .content {\",\n \" white-space: pre-wrap;\",\n \" word-wrap: break-word;\",\n \" }\",\n \" .duplicate-note {\",\n \" font-style: italic;\",\n \" color: #856404;\",\n \" font-size: 0.9em;\",\n \" margin-top: 8px;\",\n \" }\",\n \" h1 {\",\n \" text-align: center;\",\n \" color: #2c3e50;\",\n \" margin-bottom: 20px;\",\n \" font-size: 1.8em;\",\n \" }\",\n \" code {\",\n \" background-color: #f5f5f5;\",\n \" padding: 2px 4px;\",\n \" border-radius: 3px;\",\n \" font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;\",\n \" }\",\n \" pre {\",\n \" background-color: #f5f5f5;\",\n \" padding: 10px;\",\n \" border-radius: 5px;\",\n \" overflow-x: auto;\",\n \" }\",\n \" </style>\",\n \"</head>\",\n \"<body>\",\n f\" <h1>{title}</h1>\",\n ]\n\n current_session = None\n last_message_content = None\n\n for message in messages:\n message_type = message.get(\"type\", \"unknown\")\n\n # Extract message content first to check for duplicates\n message_content = message.get(\"message\", {})\n if isinstance(message_content, dict):\n content = message_content.get(\"content\", \"\")\n else:\n content = message_content\n\n text_content = extract_text_content(content)\n if not text_content.strip():\n continue\n\n # Handle system messages with command names\n is_system = is_system_message(text_content, message_content)\n is_command_message = (\n \"<command-name>\" in text_content and \"<command-message>\" in text_content\n )\n\n if is_system and not is_command_message:\n continue\n\n # Check if we're in a new session\n source_file = message.get(\"_source_file\", \"unknown\")\n is_new_session = current_session != source_file\n\n # Check for duplicate message at session boundary\n is_duplicate = (\n is_new_session\n and last_message_content is not None\n and text_content.strip() == last_message_content.strip()\n )\n\n if is_new_session:\n if current_session is not None: # Don't add divider before first session\n html_parts.append(\n f\" <div class='session-divider'>Session: {source_file}</div>\"\n )\n else:\n html_parts.append(\n f\" <div class='session-divider'>Session: {source_file}</div>\"\n )\n current_session = source_file\n\n timestamp = message.get(\"timestamp\", \"\")\n formatted_timestamp = format_timestamp(timestamp) if timestamp else \"\"\n\n # Determine CSS class and content based on message type and duplicate status\n if message_type == \"summary\":\n css_class = \"summary\"\n summary_text = message.get(\"summary\", \"Summary\")\n content_html = f\"<strong>Summary:</strong> {escape_html(str(summary_text))}\"\n elif is_command_message:\n css_class = \"system\"\n command_name = extract_command_name(text_content)\n escaped_command_name = escape_html(command_name)\n escaped_content = escape_html(text_content)\n content_html = f\"<details><summary>Command: {escaped_command_name}</summary><div class='content'>{escaped_content}</div></details>\"\n message_type = \"system\"\n else:\n css_class = f\"{message_type}\"\n content_html = escape_html(text_content)\n\n if is_duplicate:\n css_class += \" duplicate-collapsed\"\n content_html = \"<div class='duplicate-note'>(Duplicate from previous session - collapsed)</div>\"\n\n html_parts.extend(\n [\n f\" <div class='message {css_class}'>\",\n \" <div class='header'>\",\n f\" <span>{message_type.title()}</span>\",\n f\" <span class='timestamp'>{formatted_timestamp}</span>\",\n \" </div>\",\n f\" <div class='content'>{content_html}</div>\",\n \" </div>\",\n ]\n )\n\n # Update last message content for duplicate detection\n last_message_content = text_content\n\n html_parts.extend([\"</body>\", \"</html>\"])\n\n return \"\\n\".join(html_parts)\n\n\ndef convert_jsonl_to_html(\n input_path: Path,\n output_path: Optional[Path] = None,\n from_date: Optional[str] = None,\n to_date: Optional[str] = None,\n) -> Path:\n \"\"\"Convert JSONL transcript(s) to HTML file.\"\"\"\n if not input_path.exists():\n raise FileNotFoundError(f\"Input path not found: {input_path}\")\n\n if input_path.is_file():\n # Single file mode\n if output_path is None:\n output_path = input_path.with_suffix(\".html\")\n messages = load_transcript(input_path)\n title = f\"Claude Transcript - {input_path.stem}\"\n else:\n # Directory mode\n if output_path is None:\n output_path = input_path / \"combined_transcripts.html\"\n messages = load_directory_transcripts(input_path)\n title = f\"Claude Transcripts - {input_path.name}\"\n\n # Apply date filtering\n messages = filter_messages_by_date(messages, from_date, to_date)\n\n # Update title to include date range if specified\n if from_date or to_date:\n date_range_parts = []\n if from_date:\n date_range_parts.append(f\"from {from_date}\")\n if to_date:\n date_range_parts.append(f\"to {to_date}\")\n date_range_str = \" \".join(date_range_parts)\n title += f\" ({date_range_str})\"\n\n html_content = generate_html(messages, title)\n output_path.write_text(html_content, encoding=\"utf-8\")\n return output_path\n",
26→ "numLines": 442,
27→ "startLine": 1,
28→ "totalLines": 442
29→ }
30→ }
31→}